/* * Copyright 2013 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.android.apps.dashclock.weather; import com.google.android.apps.dashclock.LogUtils; import com.google.android.apps.dashclock.Utils; import com.google.android.apps.dashclock.api.DashClockExtension; import com.google.android.apps.dashclock.api.ExtensionData; import com.google.android.apps.dashclock.configuration.AppChooserPreference; import net.nurik.roman.dashclock.R; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import org.xmlpull.v1.XmlPullParserFactory; import android.app.AlarmManager; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.location.Criteria; import android.location.Location; import android.location.LocationListener; import android.location.LocationManager; import android.location.LocationProvider; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.SystemClock; import android.preference.PreferenceManager; import android.text.TextUtils; import android.util.Pair; import java.io.IOException; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Locale; import static com.google.android.apps.dashclock.LogUtils.LOGD; import static com.google.android.apps.dashclock.LogUtils.LOGE; import static com.google.android.apps.dashclock.LogUtils.LOGW; /** * A local weather and forecast extension. */ public class WeatherExtension extends DashClockExtension { private static final String TAG = LogUtils.makeLogTag(WeatherExtension.class); public static final String PREF_WEATHER_UNITS = "pref_weather_units"; public static final String PREF_WEATHER_SHORTCUT = "pref_weather_shortcut"; public static final Intent DEFAULT_WEATHER_INTENT = new Intent(Intent.ACTION_VIEW, Uri.parse("https://www.google.com/search?q=weather")); public static final String STATE_WEATHER_LAST_BACKOFF_MILLIS = "state_weather_last_backoff_millis"; private static final long STALE_LOCATION_NANOS = 10l * 60000000000l; // 10 minutes private static final int INITIAL_BACKOFF_MILLIS = 30000; // 30 seconds for first error retry private static final int LOCATION_TIMEOUT_MILLIS = 60000; // 60 sec timeout for location attempt private static XmlPullParserFactory sXmlPullParserFactory; private static final Criteria sLocationCriteria; private static String sWeatherUnits = "f"; private static Intent sWeatherIntent; private boolean mOneTimeLocationListenerActive = false; private Handler mTimeoutHandler = new Handler(); static { sLocationCriteria = new Criteria(); sLocationCriteria.setPowerRequirement(Criteria.POWER_LOW); sLocationCriteria.setAccuracy(Criteria.ACCURACY_COARSE); sLocationCriteria.setCostAllowed(false); } static { try { sXmlPullParserFactory = XmlPullParserFactory.newInstance(); sXmlPullParserFactory.setNamespaceAware(true); } catch (XmlPullParserException e) { LOGE(TAG, "Could not instantiate XmlPullParserFactory", e); } } private void resetAndCancelRetries() { SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(this); sp.edit().remove(STATE_WEATHER_LAST_BACKOFF_MILLIS).apply(); AlarmManager am = (AlarmManager) getSystemService(ALARM_SERVICE); am.cancel(WeatherRetryReceiver.getPendingIntent(this)); } private void scheduleRetry() { SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(this); int lastBackoffMillis = sp.getInt(STATE_WEATHER_LAST_BACKOFF_MILLIS, 0); int backoffMillis = (lastBackoffMillis > 0) ? lastBackoffMillis * 2 : INITIAL_BACKOFF_MILLIS; sp.edit().putInt(STATE_WEATHER_LAST_BACKOFF_MILLIS, backoffMillis).apply(); LOGD(TAG, "Scheduling weather retry in " + (backoffMillis / 1000) + " second(s)"); AlarmManager am = (AlarmManager) getSystemService(ALARM_SERVICE); am.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + backoffMillis, WeatherRetryReceiver.getPendingIntent(this)); } @Override protected void onUpdateData(int reason) { LOGD(TAG, "Attempting weather update; reason=" + reason); SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(this); sWeatherUnits = sp.getString(PREF_WEATHER_UNITS, sWeatherUnits); sWeatherIntent = AppChooserPreference.getIntentValue( sp.getString(PREF_WEATHER_SHORTCUT, null), DEFAULT_WEATHER_INTENT); NetworkInfo ni = ((ConnectivityManager) getSystemService( Context.CONNECTIVITY_SERVICE)).getActiveNetworkInfo(); if (ni == null || !ni.isConnected()) { LOGD(TAG, "No network connection; not attempting to update weather."); return; } LocationManager lm = (LocationManager) getSystemService(Context.LOCATION_SERVICE); String provider = lm.getBestProvider(sLocationCriteria, true); if (TextUtils.isEmpty(provider)) { publishErrorUpdate(new CantGetWeatherException(false, R.string.no_location_data, "No available location providers matching criteria.")); return; } final Location lastLocation = lm.getLastKnownLocation(provider); if (lastLocation == null || (SystemClock.elapsedRealtimeNanos() - lastLocation.getElapsedRealtimeNanos()) >= STALE_LOCATION_NANOS) { LOGW(TAG, "Stale or missing last-known location; requesting single coarse location " + "update."); disableOneTimeLocationListener(); mOneTimeLocationListenerActive = true; lm.requestSingleUpdate(provider, mOneTimeLocationListener, null); // Time-out single location update request mTimeoutHandler.removeCallbacksAndMessages(null); mTimeoutHandler.postDelayed(new Runnable() { @Override public void run() { LOGE(TAG, "Location request timed out."); disableOneTimeLocationListener(); scheduleRetry(); } }, LOCATION_TIMEOUT_MILLIS); } else { getWeatherAndPublishUpdate(lastLocation); } } private void disableOneTimeLocationListener() { if (mOneTimeLocationListenerActive) { LocationManager lm = (LocationManager) getSystemService(Context.LOCATION_SERVICE); lm.removeUpdates(mOneTimeLocationListener); mOneTimeLocationListenerActive = false; } } private LocationListener mOneTimeLocationListener = new LocationListener() { @Override public void onLocationChanged(Location location) { LOGD(TAG, "Got network location update"); mTimeoutHandler.removeCallbacksAndMessages(null); getWeatherAndPublishUpdate(location); disableOneTimeLocationListener(); } @Override public void onStatusChanged(String provider, int status, Bundle extras) { LOGD(TAG, "Network location provider status change: " + status); if (status == LocationProvider.TEMPORARILY_UNAVAILABLE) { scheduleRetry(); disableOneTimeLocationListener(); } } @Override public void onProviderEnabled(String provider) { } @Override public void onProviderDisabled(String provider) { } }; @Override public void onDestroy() { super.onDestroy(); disableOneTimeLocationListener(); } private void getWeatherAndPublishUpdate(Location location) { try { WeatherData weatherData = getWeatherForLocation(location); publishWeatherUpdate(weatherData); resetAndCancelRetries(); } catch (CantGetWeatherException e) { publishErrorUpdate(e); if (e.isRetryable()) { scheduleRetry(); } } } private void publishErrorUpdate(CantGetWeatherException e) { LOGE(TAG, "Showing a weather extension error", e); publishUpdate(new ExtensionData() .visible(true) .clickIntent(sWeatherIntent) .icon(R.drawable.ic_weather_clear) .status(getString(R.string.status_none)) .expandedBody(getString(e.getUserFacingErrorStringId()))); } private void publishWeatherUpdate(WeatherData weatherData) { String temperature = weatherData.hasValidTemperature() ? getString(R.string.temperature_template, weatherData.temperature) : getString(R.string.status_none); StringBuilder expandedBody = new StringBuilder(); int conditionIconId = WeatherData.getConditionIconId(weatherData.conditionCode); if (WeatherData.getConditionIconId(weatherData.todayForecastConditionCode) == R.drawable.ic_weather_raining) { // Show rain if it will rain today. conditionIconId = R.drawable.ic_weather_raining; expandedBody.append( getString(R.string.later_forecast_template, weatherData.forecastText)); } if (expandedBody.length() > 0) { expandedBody.append("\n"); } expandedBody.append(weatherData.location); publishUpdate(new ExtensionData() .visible(true) .clickIntent(sWeatherIntent) .status(temperature) .expandedTitle(getString(R.string.weather_expanded_title_template, temperature + sWeatherUnits.toUpperCase(Locale.US), weatherData.conditionText)) .icon(conditionIconId) .expandedBody(expandedBody.toString())); } private static WeatherData getWeatherForLocation(Location location) throws CantGetWeatherException { LOGD(TAG, "Using location: " + location.getLatitude() + "," + location.getLongitude()); // Honolulu = 2423945 // Paris = 615702 // London = 44418 // New York = 2459115 // San Francisco = 2487956 LocationInfo locationInfo = getLocationInfo(location); // Loop through the woeids (they're in descending precision order) until weather data // is found. for (String woeid : locationInfo.woeids) { LOGD(TAG, "Trying WOEID: " + woeid); WeatherData data = getWeatherDataForWoeid(woeid, locationInfo.town); if (data != null && data.conditionCode != WeatherData.INVALID_CONDITION && data.temperature != WeatherData.INVALID_TEMPERATURE) { return data; } } // No weather could be found :( throw new CantGetWeatherException(true, R.string.no_weather_data); } private static WeatherData getWeatherDataForWoeid(String woeid, String town) throws CantGetWeatherException { HttpURLConnection connection = null; try { connection = Utils.openUrlConnection(buildWeatherQueryUrl(woeid)); XmlPullParser xpp = sXmlPullParserFactory.newPullParser(); xpp.setInput(new InputStreamReader(connection.getInputStream())); WeatherData data = new WeatherData(); boolean hasTodayForecast = false; int eventType = xpp.getEventType(); while (eventType != XmlPullParser.END_DOCUMENT) { if (eventType == XmlPullParser.START_TAG && "condition".equals(xpp.getName())) { for (int i = xpp.getAttributeCount() - 1; i >= 0; i--) { if ("temp".equals(xpp.getAttributeName(i))) { data.temperature = Integer.parseInt(xpp.getAttributeValue(i)); } else if ("code".equals(xpp.getAttributeName(i))) { data.conditionCode = Integer.parseInt(xpp.getAttributeValue(i)); } else if ("text".equals(xpp.getAttributeName(i))) { data.conditionText = xpp.getAttributeValue(i); } } } else if (eventType == XmlPullParser.START_TAG && "forecast".equals(xpp.getName()) && !hasTodayForecast) { // TODO: verify this is the forecast for today (this currently assumes the // first forecast is today's forecast) hasTodayForecast = true; for (int i = xpp.getAttributeCount() - 1; i >= 0; i--) { if ("code".equals(xpp.getAttributeName(i))) { data.todayForecastConditionCode = Integer.parseInt(xpp.getAttributeValue(i)); } else if ("text".equals(xpp.getAttributeName(i))) { data.forecastText = xpp.getAttributeValue(i); } } } else if (eventType == XmlPullParser.START_TAG && "location".equals(xpp.getName())) { String cityOrVillage = "--"; String region = null; String country = "--"; for (int i = xpp.getAttributeCount() - 1; i >= 0; i--) { if ("city".equals(xpp.getAttributeName(i))) { cityOrVillage = xpp.getAttributeValue(i); } else if ("region".equals(xpp.getAttributeName(i))) { region = xpp.getAttributeValue(i); } else if ("country".equals(xpp.getAttributeName(i))) { country = xpp.getAttributeValue(i); } } if (TextUtils.isEmpty(region)) { // If no region is available, show the country. Otherwise, don't // show country information. region = country; } if (!TextUtils.isEmpty(town) && !town.equals(cityOrVillage)) { // If a town is available and it's not equivalent to the city name, // show it. cityOrVillage = cityOrVillage + ", " + town; } data.location = cityOrVillage + ", " + region; } eventType = xpp.next(); } if (TextUtils.isEmpty(data.location)) { data.location = town; } return data; } catch (IOException e) { throw new CantGetWeatherException(true, R.string.no_weather_data, "Error parsing weather feed XML.", e); } catch (XmlPullParserException e) { throw new CantGetWeatherException(true, R.string.no_weather_data, "Error parsing weather feed XML.", e); } finally { if (connection != null) { connection.disconnect(); } } } private static LocationInfo getLocationInfo(Location location) throws CantGetWeatherException { LocationInfo li = new LocationInfo(); // first=tagname (admin1, locality3) second=woeid String primaryWoeid = null; List<Pair<String,String>> alternateWoeids = new ArrayList<Pair<String, String>>(); HttpURLConnection connection = null; try { connection = Utils.openUrlConnection(buildPlaceSearchUrl(location)); XmlPullParser xpp = sXmlPullParserFactory.newPullParser(); xpp.setInput(new InputStreamReader(connection.getInputStream())); boolean inWoe = false; boolean inTown = false; int eventType = xpp.getEventType(); while (eventType != XmlPullParser.END_DOCUMENT) { String tagName = xpp.getName(); if (eventType == XmlPullParser.START_TAG && "woeid".equals(tagName)) { inWoe = true; } else if (eventType == XmlPullParser.TEXT && inWoe) { primaryWoeid = xpp.getText(); } if (eventType == XmlPullParser.START_TAG && (tagName.startsWith("locality") || tagName.startsWith("admin"))) { for (int i = xpp.getAttributeCount() - 1; i >= 0; i--) { String attrName = xpp.getAttributeName(i); if ("type".equals(attrName) && "Town".equals(xpp.getAttributeValue(i))) { inTown = true; } else if ("woeid".equals(attrName)) { String woeid = xpp.getAttributeValue(i); if (!TextUtils.isEmpty(woeid)) { alternateWoeids.add( new Pair<String, String>(tagName, woeid)); } } } } else if (eventType == XmlPullParser.TEXT && inTown) { li.town = xpp.getText(); } if (eventType == XmlPullParser.END_TAG) { inWoe = false; inTown = false; } eventType = xpp.next(); } // Add the primary woeid if it was found. if (!TextUtils.isEmpty(primaryWoeid)) { li.woeids.add(primaryWoeid); } // Sort by descending tag name to order by decreasing precision // (locality3, locality2, locality1, admin3, admin2, admin1, etc.) Collections.sort(alternateWoeids, new Comparator<Pair<String, String>>() { @Override public int compare(Pair<String, String> pair1, Pair<String, String> pair2) { return pair1.first.compareTo(pair2.first); } }); for (Pair<String, String> pair : alternateWoeids) { li.woeids.add(pair.second); } if (li.woeids.size() > 0) { return li; } throw new CantGetWeatherException(true, R.string.no_weather_data, "No WOEIDs found nearby."); } catch (IOException e) { throw new CantGetWeatherException(true, R.string.no_weather_data, "Error parsing place search XML", e); } catch (XmlPullParserException e) { throw new CantGetWeatherException(true, R.string.no_weather_data, "Error parsing place search XML", e); } finally { if (connection != null) { connection.disconnect(); } } } private static String buildWeatherQueryUrl(String woeid) { // http://developer.yahoo.com/weather/ return "http://weather.yahooapis.com/forecastrss?w=" + woeid + "&u=" + sWeatherUnits; } private static String buildPlaceSearchUrl(Location l) { // GeoPlanet API return "http://where.yahooapis.com/v1/places.q('" + l.getLatitude() + "," + l.getLongitude() + "')" + "?appid=kGO140TV34HVTae_DDS93fM_w3AJmtmI23gxUFnHKWyrOGcRzoFjYpw8Ato6BxhvbTg-"; } private static class LocationInfo { // Sorted by decreasing precision // (point of interest, locality3, locality2, locality1, admin3, admin2, admin1, etc.) List<String> woeids = new ArrayList<String>(); String town; } public static class CantGetWeatherException extends Exception { int mUserFacingErrorStringId; boolean mRetryable; public CantGetWeatherException(boolean retryable, int userFacingErrorStringId) { this(retryable, userFacingErrorStringId, null, null); } public CantGetWeatherException(boolean retryable, int userFacingErrorStringId, String detailMessage) { this(retryable, userFacingErrorStringId, detailMessage, null); } public CantGetWeatherException(boolean retryable, int userFacingErrorStringId, String detailMessage, Throwable throwable) { super(detailMessage, throwable); mUserFacingErrorStringId = userFacingErrorStringId; mRetryable = retryable; } public int getUserFacingErrorStringId() { return mUserFacingErrorStringId; } public boolean isRetryable() { return mRetryable; } } }